Skip to content

sec(auth): stop leaking MFA enrollment via login error responses (closes #388)#886

Closed
cristim wants to merge 1 commit into
feat/multicloud-web-frontendfrom
fix/388-wave21
Closed

sec(auth): stop leaking MFA enrollment via login error responses (closes #388)#886
cristim wants to merge 1 commit into
feat/multicloud-web-frontendfrom
fix/388-wave21

Conversation

@cristim

@cristim cristim commented May 30, 2026

Copy link
Copy Markdown
Member

Summary

  • Add ErrMFANotConfigured sentinel to internal/auth/errors.go and wrap it in verifyPasswordAndMFA. Map it to "mfa_required" in the login handler (same as ErrMFARequired) so an attacker cannot distinguish "MFA enrolled and working" from "MFA enrolled but secret missing" via the 401 response.
  • Replace the bare err.Error() fallthrough in the login handler with a fixed "invalid credentials" string so no internal error message ever reaches the client.
  • Coordinates with PR refactor(auth/mfa): use sentinel errors in mapMFAServiceError #883 (sentinel errors for MFA management operations): ErrMFANotConfigured message string is identical to the pre-sentinel literal, so mapMFAServiceError's substring fallback still fires correctly until refactor(auth/mfa): use sentinel errors in mapMFAServiceError #883 lands.

Test plan

  • TestLogin_FailedAuth_ResponseEquivalence positively asserts that wrong-password for a no-MFA user and wrong-password for an MFA-enrolled user both return HTTP 401 + "invalid credentials" -- identical bodies.
  • TestLogin_MFANotConfigured_ReturnsMFARequired asserts ErrMFANotConfigured produces 401 + "mfa_required", not the internal error string.
  • TestLogin_MFARequired_And_NotConfigured_ProduceSameResponse cross-checks ErrMFARequired and ErrMFANotConfigured produce the exact same status code and response body.
  • TestLogin_WithMFA_NoSecret (auth package) updated to use errors.Is(err, ErrMFANotConfigured) -- a stronger assertion than Contains.
  • Full suite: go test ./internal/auth/... ./internal/api/... -- 1857 tests pass.

Summary by CodeRabbit

Release Notes

  • Bug Fixes
    • Login error messages now provide consistent responses for all authentication failures, preventing variation based on account configuration details.

@cristim cristim added triaged Item has been triaged priority/p2 Backlog-worthy urgency/this-quarter Within the quarter impact/few Limited audience effort/m Days type/security Security finding labels May 30, 2026
@cristim

cristim commented May 30, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented May 30, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

The PR adds a machine-detectable ErrMFANotConfigured sentinel error to distinguish the case when MFA is enabled but the secret is missing. The auth service now returns this sentinel instead of a generic string error. The API handler maps this sentinel to the same normalized 401 "mfa_required" response as other MFA-required cases, and makes all other login failures return a generic 401 "invalid credentials" to prevent leaking whether a user has MFA configured. Regression tests verify response equivalence across credential and MFA failure paths.

Changes

MFA not-configured error handling

Layer / File(s) Summary
Error sentinel contract and auth service integration
internal/auth/errors.go, internal/auth/service.go, internal/auth/service_test.go
ErrMFANotConfigured sentinel is defined and documented for use when MFA is enabled but the secret is missing. The auth service's verifyPasswordAndMFA wraps and returns this sentinel instead of a string error. Service tests updated to validate the sentinel using errors.Is matching.
Handler mapping and login error normalization
internal/api/handler_auth.go, internal/api/handler_auth_test.go
Login handler detects ErrMFANotConfigured and maps it to 401 "mfa_required", matching the response for ErrMFARequired. All other unhandled login errors now return a generic 401 "invalid credentials" instead of exposing error details. New test helper ErrMFANotConfigured_test() and three regression tests verify that wrong-credentials and MFA-enrollment failures produce identical responses to prevent information leakage (issue #388).

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

  • LeanerCloud/CUDly#887: Introduces similar login error response normalization patterns for non-existent user vs. wrong password cases, complementing this PR's MFA configuration leakage prevention.

Suggested labels

severity/medium

Poem

🐰 A secret left unconfigured, now caught with care,
No whispers in the error that reveal the pair,
One sentinel for all who seek to authenticate,
Whether MFA waits or keys don't quite relate,
Security through silence—a safer way to gate!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and specifically describes the main security objective: preventing MFA enrollment information leakage via login error responses, and references the related issue #388.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/388-wave21

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai

coderabbitai Bot commented May 30, 2026

Copy link
Copy Markdown
Contributor
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@cristim

cristim commented Jun 3, 2026

Copy link
Copy Markdown
Member Author

Rebased fix/388-wave21 onto latest feat/multicloud-web-frontend (was DIRTY).

Conflict resolution: only internal/api/handler_auth_test.go conflicted, and it was purely additive. The base branch had added the GET /api/auth/me/permissions test block (issues #917/#922) plus the ErrMFARequired_test/ErrInvalidMFACode_test sentinel helpers; this PR added a third helper ErrMFANotConfigured_test and the three issue-#388 MFA-equivalence tests. Kept both: a single de-duplicated trio of sentinel helpers, the base permissions tests, and this PR's #388 tests. No semantic divergence in the security logic.

Prod-side files (handler_auth.go, auth/errors.go, auth/service.go) auto-merged cleanly; the ErrMFANotConfigured sentinel and its handler mapping survived intact.

Verification on the rebased tree: gofmt clean, go vet clean, go build ok, go test ./internal/api/... ./internal/auth/... all green (including the #388 equivalence tests and the #917/#922 permissions tests together).

No prior CodeRabbit findings to address: the only earlier CR activity on this PR was a per-PR review-rate-limit notice (2026-05-30), so this is the first substantive review request.

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jun 3, 2026

Copy link
Copy Markdown
Contributor

@cristim: Acknowledged! Triggering a fresh review of the rebased fix/388-wave21 branch. I'll examine the MFA enrollment leak fix and the conflict-resolution additions in handler_auth_test.go as part of this pass.


🧠 Learnings used
Learnt from: cristim
Repo: LeanerCloud/CUDly PR: 0
File: :0-0
Timestamp: 2026-05-05T15:44:54.463Z
Learning: In the LeanerCloud/CUDly repository, when a CodeRabbit review flags something as "outside-diff" that was already implemented in an earlier commit of the same PR, it is likely a stale-snapshot artifact where the review diff was generated against a pre-fix view. The inline comment convention `(CR pass on PR #<number> Finding N)` is used by the maintainer (cristim) to mark lines that were changed in direct response to a prior CodeRabbit review pass.
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@cristim

cristim commented Jun 3, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jun 3, 2026

Copy link
Copy Markdown
Contributor
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

 #388)

The login handler previously forwarded err.Error() verbatim for any
auth failure not matching ErrMFARequired or ErrInvalidMFACode. This
included "MFA is enabled but not configured", which revealed to an
attacker that credentials were correct and MFA was enrolled (but
broken). Two attack vectors were closed:

1. Add ErrMFANotConfigured sentinel to internal/auth/errors.go and
   return it (wrapped via %w) from verifyPasswordAndMFA. Map it to
   "mfa_required" in the login handler -- identical to ErrMFARequired
   -- so "MFA enrolled + working" is indistinguishable from "MFA
   enrolled + broken secret" in the HTTP response.

2. Replace the bare err.Error() fallthrough in the login handler with
   a fixed "invalid credentials" string so no internal error message
   ever reaches the client.

Tests positively assert response equivalence: ErrMFARequired and
ErrMFANotConfigured produce the same 401 + "mfa_required" body; all
wrong-password paths produce the same 401 + "invalid credentials" body.
@cristim

cristim commented Jun 3, 2026

Copy link
Copy Markdown
Member Author

Rebased on feat/multicloud-web-frontend (advanced by sibling merges #887, #819, #789).

Single content conflict, resolved as an additive merge in internal/api/handler_auth_test.go: the base had added TestHandler_login_ErrorEquivalence (issue #416 username-enumeration guard) at the same location where this PR adds the three issue #388 MFA-leak guards (TestLogin_FailedAuth_ResponseEquivalence, TestLogin_MFANotConfigured_ReturnsMFARequired, TestLogin_MFARequired_And_NotConfigured_ProduceSameResponse). Kept both sets of tests; no semantic or renamed-field divergence. The other four touched auth files (handler_auth.go, errors.go, service.go, service_test.go) auto-merged cleanly.

Post-rebase: gofmt clean, go vet clean, build clean, internal/api + internal/auth test suites green. Pushed as 804ff301d.

@coderabbitai review

@coderabbitai

coderabbitai Bot commented Jun 3, 2026

Copy link
Copy Markdown
Contributor

@cristim: Acknowledged! Triggering a fresh review of the rebased fix/388-wave21 branch at 804ff301d. I'll look at the TestHandler_login_ErrorEquivalence additive merge alongside the three issue #388 MFA-leak guard tests, as well as the clean auto-merges in the four prod-side auth files.


🧠 Learnings used
Learnt from: cristim
Repo: LeanerCloud/CUDly PR: 0
File: :0-0
Timestamp: 2026-05-05T15:44:54.463Z
Learning: In the LeanerCloud/CUDly repository, when a CodeRabbit review flags something as "outside-diff" that was already implemented in an earlier commit of the same PR, it is likely a stale-snapshot artifact where the review diff was generated against a pre-fix view. The inline comment convention `(CR pass on PR #<number> Finding N)` is used by the maintainer (cristim) to mark lines that were changed in direct response to a prior CodeRabbit review pass.
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@internal/api/handler_auth.go`:
- Around line 60-63: The current handler_auth.go maps every error returned from
auth.Login to NewClientError(401, "invalid credentials"), hiding non-auth
issues; change the logic in the code that calls auth.Login to only return the
opaque 401 for the explicit normalized credential-failure sentinel (e.g.,
auth.ErrInvalidCredentials or whatever specific value/type the auth package
uses), and for any other error returned by auth.Login let it propagate or map to
an appropriate code (e.g., return the original error, convert validation errors
to 400, and server-side errors to 500) instead of collapsing them into 401;
update the conditional that currently does return nil, NewClientError(401,
"invalid credentials") to check the concrete error identity/type first and
handle other errors separately.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 512222fa-9809-4e32-afdb-628721e40d02

📥 Commits

Reviewing files that changed from the base of the PR and between e3ecc7c and 804ff30.

📒 Files selected for processing (5)
  • internal/api/handler_auth.go
  • internal/api/handler_auth_test.go
  • internal/auth/errors.go
  • internal/auth/service.go
  • internal/auth/service_test.go

Comment on lines +60 to +63
// All other auth failures (wrong password, account not found,
// locked, etc.) collapse to a single opaque 401. Never forward
// err.Error() verbatim — it may reveal internal account state.
return nil, NewClientError(401, "invalid credentials")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Don't collapse unexpected login failures into 401s.

auth.Login can still return non-auth errors like invalid email format and failed to create session: ... from internal/auth/service.go. Mapping all of those to 401 invalid credentials will hide real outages and misclassify bad requests as auth failures. Keep the opaque 401 only for the normalized credential-failure path, and let everything else bubble or map separately.

Suggested fix
-		// All other auth failures (wrong password, account not found,
-		// locked, etc.) collapse to a single opaque 401. Never forward
-		// err.Error() verbatim — it may reveal internal account state.
-		return nil, NewClientError(401, "invalid credentials")
+		// Only the normalized credential-failure path should collapse to
+		// the opaque 401. Let validation/infrastructure errors preserve
+		// their semantics upstream.
+		switch err.Error() {
+		case "invalid email or password":
+			return nil, NewClientError(401, "invalid credentials")
+		case "invalid email format":
+			return nil, NewClientError(400, "invalid email format")
+		default:
+			return nil, err
+		}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// All other auth failures (wrong password, account not found,
// locked, etc.) collapse to a single opaque 401. Never forward
// err.Error() verbatim — it may reveal internal account state.
return nil, NewClientError(401, "invalid credentials")
// Only the normalized credential-failure path should collapse to
// the opaque 401. Let validation/infrastructure errors preserve
// their semantics upstream.
switch err.Error() {
case "invalid email or password":
return nil, NewClientError(401, "invalid credentials")
case "invalid email format":
return nil, NewClientError(400, "invalid email format")
default:
return nil, err
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/api/handler_auth.go` around lines 60 - 63, The current
handler_auth.go maps every error returned from auth.Login to NewClientError(401,
"invalid credentials"), hiding non-auth issues; change the logic in the code
that calls auth.Login to only return the opaque 401 for the explicit normalized
credential-failure sentinel (e.g., auth.ErrInvalidCredentials or whatever
specific value/type the auth package uses), and for any other error returned by
auth.Login let it propagate or map to an appropriate code (e.g., return the
original error, convert validation errors to 400, and server-side errors to 500)
instead of collapsing them into 401; update the conditional that currently does
return nil, NewClientError(401, "invalid credentials") to check the concrete
error identity/type first and handle other errors separately.

@cristim

cristim commented Jun 3, 2026

Copy link
Copy Markdown
Member Author

pr-iterate auto-rebase stopped: genuine semantic conflict (not the expected additive test merge).

The latest merge wave landed #830 (878046eae -- "sec(auth): collapse MFA-not-configured into generic auth failure (closes #391)"), which directly contradicts this PR's design for the SAME code path (verifyPasswordAndMFA in internal/auth/service.go, MFA-enabled-but-secret-missing branch):

Both close a real leak but pick opposite indistinguishability targets, and each ships a test asserting its own outcome (base: TestLogin_WithMFA_NoSecret asserts the generic message; this PR: assert.ErrorIs(err, ErrMFANotConfigured)). They cannot both hold at the service layer.

This needs a human security decision on which leak model wins before the rebase can proceed:
(a) keep #830's generic-message collapse and drop this PR's ErrMFANotConfigured path (this PR likely becomes a no-op / closeable as superseded by #391), or
(b) supersede #830 here -- keep ErrMFANotConfigured -> mfa_required, delete the base's TestLogin_WithMFA_NoSecret generic-message subtest, and reconcile the comment in service.go.

Rebase was aborted; branch is unchanged at 804ff30. No force-push, no CR re-ping. Re-run pr-iterate after the direction is chosen.

@cristim

cristim commented Jun 3, 2026

Copy link
Copy Markdown
Member Author

Closing as superseded. The MFA-enrollment leak this targets (#388) is already fixed by the merged PR #830 (closes #391), which collapses the missing-MFA-secret branch into the generic wrong-credentials response - a strictly stronger anti-enumeration posture than this PR's typed mfa_required response (it hides even that MFA is enrolled for the broken-secret case). The service-layer ErrMFANotConfigured -> mfa_required approach here directly conflicts with #830's generic collapse and cannot coexist with it.

The one genuinely-unique improvement in this PR - replacing the handler_auth.go login catch-all 'return NewClientError(401, err.Error())' with an opaque 'invalid credentials' so raw service errors are never forwarded - is being carried forward as a small standalone hygiene follow-up issue. Tracking there.

@cristim cristim closed this Jun 3, 2026
cristim added a commit that referenced this pull request Jun 3, 2026
…ror() (closes #937) (#938)

The login() catch-all error path was forwarding raw service errors verbatim
via NewClientError(401, err.Error()), potentially exposing internal account
state (e.g. lock status, user IDs, timestamps) to unauthenticated callers.

Replace it with the opaque hardcoded string "invalid credentials" so all
non-sentinel auth failures (wrong password, account not found, locked, etc.)
collapse to the same response, regardless of what the service returns.

The typed-sentinel arms above (ErrMFARequired -> "mfa_required",
ErrInvalidMFACode -> "invalid_mfa_code") are unchanged.

Adds TestHandler_login_OpaqueError_HidesInternalMessage as a regression test
that pins the service returning a distinctive internal message and asserts
the 401 body is "invalid credentials" with no internal detail leaking through.

Salvaged from closed #886.
@cristim cristim deleted the fix/388-wave21 branch June 3, 2026 21:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

effort/m Days impact/few Limited audience priority/p2 Backlog-worthy triaged Item has been triaged type/security Security finding urgency/this-quarter Within the quarter

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant